iT邦幫忙

2024 iThome 鐵人賽

DAY 28
0
JavaScript

Signal API in Angular系列 第 28

Day 28 - 使用 Facade Pattern 從 Signal 遷移到 State Management Library

  • 分享至 

  • xImage
  •  

當應用程式較小且簡單時,signal就足以建構 state management 解決方案。當應用程式擴充時,我們應該考慮遷移到開源庫,例如 NGRXNGRX Signal StoreTanStack Store

由於不同的 method signatures,從一個庫遷移到另一個庫可能會導致組件發生重大變更。一種解決方案是將 facade pattern 應用於服務 (service)。然後,組件透過 interface 進行通信,而不是直接與 store 互動。

定義 Account Contract

import { InputItem } from "../types/account.type";
import { AccountSummary } from "../types/store.type";
import { Signal } from '@angular/core';

export interface AccountContract {
 summary: Signal<AccountSummary>;
 update(data: InputItem): void;
}

建立 Facade layer

import { computed, Injectable, signal, Signal } from '@angular/core';
import { AccountRecords, InputItem } from "../types/account.type";
import { AccountSummary } from '../types/store.type';
import { ItemType } from '../enums/account.enum';
import { AccountContract } from '../interfaces/account.interface';

// Angular signal implementation
@Injectable()
export class AccountFacade implements AccountContract {
 update({ type, date, amount, description }: InputItem) {
   const newItem = { date, amount, description }; 
   const state = this.#state;

   if (type === ItemType.INCOME) {
     state.update((value) => ({
       incomes: [...value.incomes, newItem],
       expenses: value.expenses,
     }));
   } else if (type === ItemType.EXPENSE) {
     state.update((value) => ({
       incomes: value.incomes,
       expenses: [...value.expenses, newItem],
     }));
   }
 }

 #state = signal<AccountRecords>({
   incomes: [],
   expenses: [],
 });

 #totalIncomes = computed(() => this.#state().incomes.reduce((acc, item) =>
     acc + item.amount, 0));

 #totalExpenses = computed(() => this.#state().expenses.reduce((acc, item) => acc + item.amount, 0));

 summary = computed<AccountSummary>(() => ({
   incomes: this.#state().incomes,
   expenses: this.#state().expenses,
   totalIncomes: this.#totalIncomes(),
   totalExpenses: this.#totalExpenses(),
   hasMoneyLeft: this.#totalIncomes() > this.#totalExpenses(),
   surplus: this.#totalIncomes() - this.#totalExpenses()
 }));
}

AccountFacade 是一項使用 signal 和 computed signal 來實現 account store 的服務 (service)。 該服務實作 (implements) 了 AccountContract 接口 (interface),因此它有一個summary 成員和一個 update 方法。

npm install @tanstack/angular-store

安裝 Angular tanstack store。

import { AccountContract } from "../interfaces/account.interface";
import { AccountRecords, InputItem } from "../types/account.type";
import { Injectable, Signal } from '@angular/core';
import { Store } from '@tanstack/store';
import { injectStore } from '@tanstack/angular-store';
import { ItemType } from "../enums/account.enum";
import { AccountSummary } from "../types/store.type";

// Tanstack Store implementation
@Injectable()
export class TanstackAccountFacade implements AccountContract {

 #store = new Store<AccountRecords>({
   incomes: [],
   expenses: [],
 });

 #totalIncomes = injectStore(this.#store, (state) => state.incomes.reduce((acc, item) => acc + item.amount, 0));

 #totalExpenses = injectStore(this.#store, (state) => state.expenses.reduce((acc, item) => acc + item.amount, 0));

 summary: Signal<AccountSummary> = injectStore(this.#store, (state) => ({
   incomes: state.incomes,
   expenses: state.expenses,
   totalIncomes: this.#totalIncomes(),
   totalExpenses: this.#totalExpenses(),
   hasMoneyLeft: this.#totalIncomes() > this.#totalExpenses(),
   surplus: this.#totalIncomes() - this.#totalExpenses()
 }))

 update({ type, date, amount, description }: InputItem) {
   const newItem = { date, amount, description }; 

   if (type === ItemType.INCOME) {
     this.#store.setState((prevState) => ({
       incomes: [...prevState.incomes, newItem],
       expenses: prevState.expenses,
     }));
   } else if (type === ItemType.EXPENSE) {
     this.#store.setState((prevState) => ({
       incomes: prevState.incomes,
       expenses: [...prevState.expenses, newItem],
     }));
   }
 }
}

TanstackAccountFacade 是一項使用 TanStack Store 程式庫來實現 account store 的服務 (service)。該服務 (service) 還實作 (implements) AccountContract 介面 (interface) 來履行合約。

宣告 InjectionToken 用於 dependency injection

export type StoreType = 'Signal' | 'TanStack';
export const STORE_TYPE = new InjectionToken<StoreType>('STORE_TYPE');
export const STORE_TOKEN = new InjectionToken<AccountContract>('STORE_TOKEN');

STORE_TYPESTORE_TOKEN InjectionToken 決定在 AppAccountWrapperComponent 組件中注入 AccountFacade 還是 TanStackAccountFacade

在組件中 provide account store

import { STORE_TOKEN, STORE_TYPE } from "./constants/account.constant";
import { AccountFacade } from "./stores/account.facade";
import { TanstackAccountFacade } from "./stores/tanstack-account.facade";
import { inject } from '@angular/core';

export const providers = [
 {
   provide: STORE_TYPE,
   useValue: 'TanStack',
 },
 {
   provide: STORE_TOKEN,
   useFactory: () => {
     const type = inject(STORE_TYPE);
     console.log('type', type);
     if (type === 'Signal') {
       return new AccountFacade();
     } else if (type === 'TanStack') {
       return new TanstackAccountFacade();
     }

     throw new Error('Wrong type');
   }
 }
]

import { providers } from './account.provider';

@Component({
 selector: 'app-account-wrapper',
 standalone: true,
 imports: [AppAccountFormComponent, AppAccountListComponent, AppAccountSummaryComponent],
 template: `
   @let summary = store.summary();
   <div class="photo-output-wrapper">
     <app-account-form class="form" />
     <h2>Balance Sheet</h2>
     <app-account-list title='Incomes' [items]="summary.incomes"  />
     <app-account-list title='Expenses' [items]="summary.expenses"  />
     <app-account-summary [summary]="summary"
     />
   </div>
 `,
 providers,
})
export default class AppAccountWrapperComponent {}

在 providers array 中,組件提供 STORE_TYPE 的值。 STORE_TOKEN InjectionToken 的 useFactory 函數注入 STORE_TYPE 來取得值。 當值為 Signal 時,程式碼會實例化 (instantiate) AccountFacade。當值為 TanStack 時,程式碼實例化 (instantiate) TanstackAccountFacade。否則,該函數會拋出錯誤訊息。

export default class AppAccountWrapperComponent {
 acountForm = viewChild.required(AppAccountFormComponent);
 store = inject(STORE_TOKEN);

 constructor() {
   effect((OnCleanUp) => {
     const sub = this.acountForm().submittedValues.subscribe((data) => this.store.update(data as InputItem));
     OnCleanUp(() => sub.unsubscribe());
   });
 }
}

AppAccountFormComponent 組件注入 STORE_TOKEN 並將 store 指派給 store 變數。該組件的作用是呼叫 store 的 update 方法以將記錄新增至 state 變數。組件的 HTML 範本不受影響。

如果我想使用 NGRX Signal Store 來實現 store,我將執行以下操作:

  • 新增服務
  • STORE_TYPE 中新增的類型成員,例如 ngrx-signal-store。
  • 更新 STORE_TOKENuseFactory 函數以實例化 (initialize) 新服務並傳回它。

最重要的是服務不必更改,因為更改在 account.provider.ts 檔案中。

結論:

  • 使用 Facade pattern 來封裝 store 的邏輯。
  • Facade pattern 可以減少組件中重大變更的數量。
  • 服務 (service) 實作一個介面 (interface) 以具有統一的契約 (unified contract)。
  • 使用 InjectionTokenuseFactory 注入正確的服務 (service)。

鐵人賽的第 28 天到此結束。

參考:


上一篇
Day 27 - Signal for state management
下一篇
Day 29 - 測試 Signals
系列文
Signal API in Angular39
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言